Feature/community tables (feat: 커뮤니티 테이블과 게시물 엔티티 추가) - #78
Conversation
🤖 Gemini PR Review
1. [HIGH] 탈퇴한 사용자에 대한 차단 해제 불가 및 요청자 검증 누락
/** 차단을 풀어도 끊긴 팔로우는 되살리지 않는다. */
@Transactional
public BlockResponse unblock(Long targetUserId, Long userId) {
// 요청자 본인이 활성 사용자인지 검증
findActiveUser(userId);
// 대상 사용자의 활성 여부와 상관없이 차단 관계 해제 진행
blockRepository.deleteByBlockerIdAndBlockedId(userId, targetUserId);
return new BlockResponse(false);
}2. [HIGH] 특정 DBMS 종속적인 네이티브 쿼리로 인한 호환성 및 테스트 실패 가능성
// BlockRepository.java 에서 insertIfAbsent 메서드 제거
// BlockService.java
@Transactional
public BlockResponse block(Long targetUserId, Long userId) {
if (targetUserId.equals(userId)) {
throw new BusinessException(ErrorCode.INVALID_BLOCK_REQUEST);
}
User blocker = findActiveUser(userId);
User blocked = findActiveUser(targetUserId);
try {
blockRepository.saveAndFlush(new Block(blocker, blocked));
} catch (DataIntegrityViolationException e) {
// 이미 차단된 경우(유니크 제약 조건 위배) 예외를 무시하여 멱등성 보장
}
followRepository.deleteByFollowerIdAndFollowingId(userId, targetUserId);
followRepository.deleteByFollowerIdAndFollowingId(targetUserId, userId);
return new BlockResponse(true);
}3. [MEDIUM] API 요청 size 파라미터의 최대값 검증 누락
@Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20")
@RequestParam(defaultValue = "20") @Min(1) @Max(50) Integer sizeModel: `gemini-3.5-flash` · API key: `PRIMARY` · Commit: `b3ebeb0` |
코드 리뷰설계 근거가 잘 잡혀 있습니다. 집계 컬럼 DB 직접 증감, 멱등 처리, 머지 전에 고쳤으면 하는 것1. 소프트 삭제한 게시물의 해시태그가 영구 소실됩니다 —
2. 오류 코드 — 물어보신 건 고치는 게 맞습니다 —
3. 해시태그가 어떤 응답에도 안 담깁니다 — 추출·저장· 상세 화면에서 태그를 못 보여주고, 수정 화면에서 현재 태그를 표시할 수도 없습니다. 태그를 눌러 필터 피드로 가는 동선이 서버 응답만으로는 구현 불가입니다. 4. 입력 상한이 없습니다 —
판단이 필요한 것5. 탈퇴 사용자의 게시물이 계속 조회됩니다 — 이 두 곳만 6. 차단이 끊은 팔로우를 바로 다시 맺을 수 있습니다 —
7. 차단 필터가 피드에만 있습니다 —
8. 동시 요청에서 멱등성이 깨집니다 —
사소한 것9. 10. 1·2번은 각각 기획 요구사항과 직접 충돌하고 31개 API 전체의 오류 응답에 영향이 있어 머지 전 처리를 권합니다. 나머지는 후속으로 가도 됩니다. |
# Conflicts: # docs/api-change-log.md
|
피드백 주신 부분 수정 내용입니다! 2. 오류 코드 — INVALID_POST_REQUEST, INVALID_USER_REQUEST를 추가해 세 경로로 나눴습니다. 댓글 경로가 게시물 하위라 먼저 보도록 순서를 뒀습니다. 3. 해시태그 응답 — 상세와 목록에 hashtags를 추가했습니다. 배치로 읽어 게시물 수가 늘어도 쿼리는 한 번입니다. 4. 입력 상한 — 본문 2000자, 댓글 1000자, 미디어 10건, 장소 태그 10건, URL 2048자입니다. mediaType은 열거형으로 바꿨습니다. 5. 탈퇴 사용자 — 두 곳만 고치면 프로필은 404인데 피드에는 남아서, 조회 경로 전체에 작성자 상태 조건을 넣었습니다. 수정·삭제는 요청자가 곧 작성자라 제외했습니다. 6. 차단 후 재팔로우 — 내가 차단한 상대를 팔로우하면 400 FOLLOW_BLOCKED_USER입니다. 나를 차단한 상대가 팔로우하면 200을 주되 관계를 만들지 않습니다. 거절하면 차단당한 사실이 드러나기때문입니다. 7. 차단 범위 — 확인해 보니 제 판단이 틀렸습니다. 인스타그램은 차단한 사용자의 댓글을 제3자 게시물에서 숨기지 않습니다. 차단은 상대가 내 계정에 접근하지 못하게 하는 것이라서요. 8. 동시성 — insert ... on conflict do nothing으로 바꿨습니다. 좋아요, 댓글 좋아요, 북마크, 팔로우, 차단 다섯 곳이며 좋아요는 실제 들어간 행 수를 보고 집계를 올립니다. 9. batchUpdate + on conflict do nothing으로 바꿔 기동 시 확인 쿼리 38회를 없앴습니다. 10. V9__add_reports_unique_constraint.sql로 고유 제약을 추가했습니다. 제약 전에 기존 중복 행을 지우는 DELETE를 뒀습니다. 계정 탈퇴 정책 검증 ./gradlew test BUILD SUCCESSFUL, 162건 통과 (기존 140 + 신규 22), 실패 0마이그레이션(now at version v9)과 ddl-auto=validate 통과를 확인했고, 복구·차단·검증 동작을 로컬에서 실제 호출로 확인했습니다. main을 머지했으며 api-change-log.md 충돌은 날짜순으로 정리해 |
재리뷰 — 지적 10건 중 9건 반영 확인대응이 꼼꼼합니다. 특히 세 가지는 제가 지적한 것보다 더 나은 방향으로 가셨습니다. 복구 API 를 새로 만드셨다 — 저는 "삭제 시 해시태그가 물리 삭제돼 복구하면 태그가 사라진다"고만 지적했는데, 복구 API 와 정리 스케줄러를 함께 만들어 차단당한 사실을 숨기신 것 — 동시성 — 반영 확인한 항목입니다.
새로 발견한 것1. 정리 스케줄러가 배포되면 아예 안 돕니다 —
|
인증 기반(#81)이 먼저 머지되면서 생긴 충돌을 해소한다. 자동 병합이 조용히 깨뜨리는 곳이 있어 하나씩 확인했다. ErrorCode 양쪽이 USER_NOT_FOUND 를 각자 추가해 자동 병합 결과에 같은 상수가 두 번 들어갔다. 그대로 두면 컴파일이 되지 않는다. 하나만 남긴다. UserRepository 양쪽이 findByIdAndDeletedAtIsNull 을 추가했다. 나머지 조회 메서드는 서로 다르므로 둘 다 남긴다. GlobalExceptionHandler 커뮤니티(posts·users·comments)와 인증(auth) 분기를 함께 둔다. 댓글 경로가 /api/v1/posts/{postId}/comments 라 게시물보다 먼저 봐야 하는 순서를 그대로 지킨다. User updateProfile 이 changeNickname·changeProfileImage 로 나뉘어, 이를 쓰던 OAuthUserRegistrarTest 를 새 메서드로 바꾼다. ERD 같은 14절에 양쪽이 다른 users 표를 썼다. 인증 컬럼이 반영된 쪽을 쓰고 커뮤니티 15~26절을 이어 붙인다. migration 번호 양쪽이 V9 를 썼다. 두 파일이 같은 버전이면 Flyway 가 기동 자체를 거부한다. 인증 쪽 V9·V10 은 이미 dev 에 적용돼 번호를 바꿀 수 없으므로 신고 고유 제약을 V11 로 옮긴다. 전체 218건 통과. 실제 PostgreSQL 에 전체 migration 적용과 JPA 스키마 검증을 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
정정과 충돌 해소먼저, 재리뷰 지적 4번은 제가 틀렸습니다"신고 중복 제약이 없다"고 적었는데, 재리뷰 지적 5건 중 실제로 남은 것은 4건입니다. 죄송합니다. 충돌은 제가 해소해서 푸시했습니다인증 기반(#81)을 먼저 머지하면서 이 PR 에 충돌을 만들었습니다. 제가 만든 문제라 제가 풀었습니다. 자동 병합이 조용히 깨뜨리는 곳이 세 군데 있었습니다.
나머지는 양쪽 내용을 합쳤습니다.
검증
남은 지적 4건머지를 막을 사유는 아니라고 봅니다. 다만 1번은 후속에서 꼭 처리해야 합니다.
|
⏺ 변경 내용
커뮤니티 기능 전체입니다. DB 스키마, API 31개, 문서, 테스트를 포함합니다.
DB — V8__create_community_tables.sql
테이블 12개, 인덱스 9개를 추가합니다. users는 V6(PR #45)에서 추가했습니다.
API 31개
PR이 큽니다. 코드를 다 보시기 부담되면 docs/API_SPEC.md의 커뮤니티 계약과 docs/ERD.md 14~26번만 봐주셔도 설계 의도는 파악되실 겁니다.
임시 조치 — X-User-Id 헤더
인증이 없어 작성자를 X-User-Id 헤더로 받습니다. 인증 도입 시 반드시 제거해야 합니다. 지금 상태로 운영에 올라가면 헤더만 바꿔 다른 사용자를 사칭할 수 있습니다. 컨트롤러마다 TODO 주석을 남겼습니다.
본문이 아니라 헤더로 받은 이유는, 조회 API에는 본문이 없어 방식이 갈라지고 나중에 Authorization 헤더로 교체할 때 DTO를 건드리지 않아도 되기 때문입니다.
주요 설계 판단
집계 컬럼은 DB에서 직접 증감시킵니다. UPDATE ... SET like_count = like_count + 1 방식입니다. 엔티티를 읽어 고쳐 쓰면 동시 요청에서 하나가 사라집니다.
좋아요·저장·팔로우·차단은 멱등합니다. 중복 요청에도 개수가 어긋나지 않습니다.
소프트 삭제에 @where를 쓰지 않았습니다. 기획상 게시물은 삭제 후 30일간 복구할 수 있어야 하는데, 전역 필터를 걸면 삭제된 글을 조회할 수단이 사라집니다. 조회 메서드마다 deleted_at IS NULL을 명시했습니다.
삭제된 댓글이 목록에 남을 수 있습니다. 살아 있는 답글이 있으면 자리를 유지하고 작성자·내용을 감춥니다. 부모가 사라지면 답글이 함께 안 보이기 때문입니다.
복합 PK 순서를 조회 방향에 맞췄습니다. bookmarks만 (user_id, post_id)로, 주 용도가 "내 북마크 목록"이라 사용자 기준 조회가 많습니다. 덕분에 별도 인덱스가 필요 없습니다.
차단은 단방향입니다. 역방향까지 막으려면 모든 조회에서 역방향 확인이 필요해 비용이 큽니다.
PostPlaceTagView로 N+1을 막았습니다. 장소 태그를 Place 엔티티로 읽으면 mappedBy로 연결된 place_details, place_operating_infos가 장소마다 조회를 일으킵니다. 피드 5건 기준 13회 → 3회로 줄었습니다.
페이징이 두 가지입니다. 커서를 만들 수 없는 곳(점수 정렬, 대리키 없는 테이블)만 오프셋을 씁니다.
알려진 문제
요청 본문 검증 실패 시 GlobalExceptionHandler.validationErrorCode()가 URI로 코드를 고르는데 커뮤니티 분기가 없어INVALID_SCHEDULE_CONDITION("일정 조건이 올바르지 않습니다")이 나갑니다.
fieldErrors는 정확합니다. 핸들러에 /api/v1/posts, /api/v1/users 분기를 추가하면 해결되는데, 공용 파일이라 임의로 고치지 않았습니다. 추가해도 될까요?
마이그레이션 번호
작업 중 V7__add_schedule_stop_times.sql이 먼저 머지되어 번호가 겹쳐 V8로 옮겼습니다.
영향
docs/API_SPEC.md, docs/ERD.md, docs/api-change-log.md를 모두 갱신했습니다. 외부 API는 사용하지 않습니다.
테스트
./gradlew test
BUILD SUCCESSFUL in 51s
140건 통과 (기존 113 + 신규 26), 실패 0, 에러 0
docker compose -f docker-compose.local.yml down -v
docker compose -f docker-compose.local.yml up -d
./gradlew bootRun --args='--spring.profiles.active=local'
Successfully applied 8 migrations, now at version v8
Started ServerApplication (ddl-auto=validate 통과)
신규 테스트 26건이 검증하는 내용입니다.
로컬에서 실제 호출로 확인한 내용입니다.
확인